diff options
Diffstat (limited to 'app/[lng]')
| -rw-r--r-- | app/[lng]/admin/edp-progress-debug/page.tsx | 210 | ||||
| -rw-r--r-- | app/[lng]/admin/edp-progress/page.tsx | 488 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx | 2 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx | 11 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx | 112 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx | 67 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx | 97 |
7 files changed, 564 insertions, 423 deletions
diff --git a/app/[lng]/admin/edp-progress-debug/page.tsx b/app/[lng]/admin/edp-progress-debug/page.tsx new file mode 100644 index 00000000..ebaa07a2 --- /dev/null +++ b/app/[lng]/admin/edp-progress-debug/page.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { debugVendorFieldCalculation } from '@/lib/forms/vendor-completion-stats'; +import { Loader, Search, FileText, Tag, CheckCircle, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +export default function DebugVendorFieldsPage() { + const [loading, setLoading] = React.useState(false); + const [vendorId, setVendorId] = React.useState('1'); + const [debugData, setDebugData] = React.useState<any>(null); + + const handleDebug = async () => { + setLoading(true); + setDebugData(null); + + try { + const result = await debugVendorFieldCalculation(Number(vendorId)); + setDebugData(result); + + if (result) { + toast.success(`${result.vendorName}의 필드 계산 디버그 완료`); + } else { + toast.warning('벤더 데이터가 없습니다'); + } + } catch (error) { + console.error('Error debugging vendor fields:', error); + toast.error(`디버그 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } finally { + setLoading(false); + } + }; + + const renderFieldDetails = (fieldDetails: any[]) => ( + <div className="space-y-1"> + {fieldDetails.map((field, index) => ( + <div key={index} className="flex items-center gap-2 text-xs"> + <span className="font-mono bg-muted px-1 rounded">{field.fieldKey}</span> + <span className="text-muted-foreground">=</span> + <span className="font-mono">{String(field.fieldValue ?? 'null')}</span> + {field.isEmpty ? ( + <XCircle className="h-3 w-3 text-red-500" /> + ) : ( + <CheckCircle className="h-3 w-3 text-green-500" /> + )} + </div> + ))} + </div> + ); + + return ( + <div className="container mx-auto p-6 space-y-6"> + <div className="flex items-center gap-2 mb-6"> + <Search className="h-6 w-6" /> + <h1 className="text-3xl font-bold">벤더 필드 계산 디버그</h1> + </div> + + {/* Input */} + <Card> + <CardHeader> + <CardTitle>벤더 ID 입력</CardTitle> + </CardHeader> + <CardContent> + <div className="flex gap-4"> + <div className="space-y-2"> + <Label htmlFor="vendorId">Vendor ID</Label> + <Input + id="vendorId" + value={vendorId} + onChange={(e) => setVendorId(e.target.value)} + placeholder="1" + type="number" + /> + </div> + <div className="flex items-end"> + <Button onClick={handleDebug} disabled={loading}> + {loading ? <Loader className="h-4 w-4 animate-spin mr-2" /> : <Search className="h-4 w-4 mr-2" />} + 디버그 실행 + </Button> + </div> + </div> + </CardContent> + </Card> + + {/* Results */} + {debugData && ( + <div className="space-y-4"> + {/* Summary */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + {debugData.vendorName} - 전체 요약 + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div className="text-center"> + <div className="text-2xl font-bold text-blue-600"> + {debugData.debugInfo.grandTotal.totalRequiredFields} + </div> + <p className="text-sm text-muted-foreground">전체 필드</p> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-green-600"> + {debugData.debugInfo.grandTotal.totalFilledFields} + </div> + <p className="text-sm text-muted-foreground">입력 필드</p> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-red-600"> + {debugData.debugInfo.grandTotal.totalEmptyFields} + </div> + <p className="text-sm text-muted-foreground">빈 필드</p> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-purple-600"> + {debugData.debugInfo.grandTotal.completionPercentage}% + </div> + <p className="text-sm text-muted-foreground">완성도</p> + </div> + </div> + </CardContent> + </Card> + + {/* Detailed Breakdown */} + <Card> + <CardHeader> + <CardTitle>상세 분석</CardTitle> + </CardHeader> + <CardContent> + <ScrollArea className="h-96"> + <div className="space-y-4"> + {debugData.debugInfo.contracts.map((contract: any, contractIndex: number) => ( + <div key={contractIndex} className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <Tag className="h-4 w-4" /> + <span className="font-semibold"> + 계약 {contract.contractId} - {contract.projectName} + </span> + <Badge variant="outline"> + 전체: {contract.totalRequiredFields} | 입력: {contract.totalFilledFields} + </Badge> + </div> + + <div className="space-y-3 ml-4"> + {contract.forms.map((form: any, formIndex: number) => ( + <div key={formIndex} className="border-l-2 border-muted pl-4"> + <div className="flex items-center gap-2 mb-2"> + <FileText className="h-3 w-3" /> + <span className="font-medium">{form.formName} ({form.formCode})</span> + <Badge variant="secondary" className="text-xs"> + 전체: {form.totalRequiredFields} | 입력: {form.totalFilledFields} + </Badge> + </div> + + <div className="space-y-2 ml-4"> + {form.tags.map((tag: any, tagIndex: number) => ( + <div key={tagIndex} className="bg-muted/50 rounded p-2"> + <div className="flex items-center gap-2 mb-1"> + <Tag className="h-3 w-3" /> + <span className="font-mono text-sm">{tag.tagNo}</span> + <Badge variant="outline" className="text-xs"> + 전체: {tag.requiredFieldsCount} | 입력: {tag.filledFieldsCount} + </Badge> + </div> + + <div className="ml-4"> + <div className="text-xs text-muted-foreground mb-1"> + 편집 가능한 필드: {tag.editableFields.join(', ')} + </div> + {renderFieldDetails(tag.fieldDetails)} + </div> + </div> + ))} + </div> + </div> + ))} + </div> + </div> + ))} + </div> + </ScrollArea> + </CardContent> + </Card> + + {/* Raw Data */} + <Card> + <CardHeader> + <CardTitle>원시 데이터 (JSON)</CardTitle> + </CardHeader> + <CardContent> + <ScrollArea className="h-64"> + <pre className="text-xs bg-muted p-4 rounded overflow-auto"> + {JSON.stringify(debugData, null, 2)} + </pre> + </ScrollArea> + </CardContent> + </Card> + </div> + )} + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/admin/edp-progress/page.tsx b/app/[lng]/admin/edp-progress/page.tsx index 4efb739c..c42a1db7 100644 --- a/app/[lng]/admin/edp-progress/page.tsx +++ b/app/[lng]/admin/edp-progress/page.tsx @@ -2,430 +2,134 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - calculateVendorFormCompletion, - getProjectVendorCompletionSummary, - calculateVendorContractCompletion, - getVendorAllContractsCompletionSummary, - getAllVendorsContractsCompletionSummary, - getAllProjectsVendorCompletionSummary, - type VendorFormCompletionStats, - type ProjectVendorCompletionSummary, - type VendorAllContractsCompletionSummary -} from '@/lib/forms/vendor-completion-stats'; -import { Loader, TestTube, BarChart, FileText, TrendingUp } from 'lucide-react'; +import { getAllVendorsContractsCompletionSummary } from '@/lib/forms/vendor-completion-stats'; +import { Loader, Users, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; -interface TestResult { - type: string; - data: VendorFormCompletionStats | ProjectVendorCompletionSummary | VendorAllContractsCompletionSummary | unknown; +interface VendorProgress { + vendorId: number; + vendorName: string; + totalForms: number; + tagCount: number; + totalRequiredFields: number; + totalFilledFields: number; + completionPercentage: number; } export default function EDPProgressTestPage() { - const [loading, setLoading] = React.useState<string | null>(null); - const [results, setResults] = React.useState<TestResult | null>(null); - - // Form inputs - const [contractItemId, setContractItemId] = React.useState('123'); - const [formCode, setFormCode] = React.useState('SPR_LST'); - const [projectId, setProjectId] = React.useState('1'); - const [vendorId, setVendorId] = React.useState('1'); + const [loading, setLoading] = React.useState(false); + const [vendorProgress, setVendorProgress] = React.useState<VendorProgress[]>([]); - const handleTest = async (testType: string, testFunction: () => Promise<unknown>) => { - setLoading(testType); - setResults(null); + const loadVendorProgress = async () => { + setLoading(true); try { - const result = await testFunction(); - setResults({ type: testType, data: result }); + const result = await getAllVendorsContractsCompletionSummary(); - if (result) { - toast.success(`${testType} 테스트 완료`); + if (result && result.vendors) { + const progressData: VendorProgress[] = result.vendors.map(vendor => ({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + totalForms: vendor.totalForms, + tagCount: vendor.totalTags, + totalRequiredFields: vendor.totalRequiredFields, + totalFilledFields: vendor.totalFilledFields, + completionPercentage: vendor.overallCompletionPercentage + })); + + setVendorProgress(progressData); + toast.success(`${progressData.length}개 벤더의 진척도를 불러왔습니다`); } else { - toast.warning(`${testType} 결과가 없습니다`); + toast.warning('벤더 데이터가 없습니다'); } } catch (error) { - console.error(`Error in ${testType}:`, error); - toast.error(`${testType} 테스트 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + console.error('Error loading vendor progress:', error); + toast.error(`벤더 진척도 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); } finally { - setLoading(null); + setLoading(false); } }; - const renderVendorFormStats = (stats: VendorFormCompletionStats) => ( - <div className="space-y-4"> - <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold text-green-600">{stats.completionPercentage}%</div> - <p className="text-sm text-muted-foreground">완성도</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold">{stats.totalFilledFields}</div> - <p className="text-sm text-muted-foreground">입력된 필드</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold">{stats.totalRequiredFields}</div> - <p className="text-sm text-muted-foreground">총 필드</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold">{stats.tagCount}</div> - <p className="text-sm text-muted-foreground">태그 수</p> - </CardContent> - </Card> - </div> - - <Card> - <CardHeader> - <CardTitle className="text-lg">태그별 세부 현황</CardTitle> - </CardHeader> - <CardContent> - <ScrollArea className="h-48"> - <div className="space-y-2"> - {stats.detailsByTag.map((tag, index) => ( - <div key={index} className="flex items-center justify-between p-2 border rounded"> - <span className="font-medium">{tag.tagNo}</span> - <div className="flex items-center gap-2"> - <Badge variant={tag.completionPercentage >= 80 ? "default" : tag.completionPercentage >= 50 ? "secondary" : "destructive"}> - {tag.completionPercentage}% - </Badge> - <span className="text-sm text-muted-foreground"> - {tag.filledFields}/{tag.requiredFields} - </span> - </div> - </div> - ))} - </div> - </ScrollArea> - </CardContent> - </Card> - </div> - ); - - const renderProjectSummary = (summary: ProjectVendorCompletionSummary) => ( - <div className="space-y-4"> - <div className="grid grid-cols-2 md:grid-cols-3 gap-4"> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold text-blue-600">{summary.averageCompletionPercentage}%</div> - <p className="text-sm text-muted-foreground">평균 완성도</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold">{summary.totalVendors}</div> - <p className="text-sm text-muted-foreground">참여 벤더</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-lg font-bold">{summary.projectCode}</div> - <p className="text-sm text-muted-foreground">프로젝트 코드</p> - </CardContent> - </Card> - </div> - - <Card> - <CardHeader> - <CardTitle className="text-lg">벤더별 완성도</CardTitle> - </CardHeader> - <CardContent> - <ScrollArea className="h-48"> - <div className="space-y-2"> - {summary.vendors.map((vendor, index) => ( - <div key={index} className="flex items-center justify-between p-2 border rounded"> - <span className="font-medium">{vendor.vendorName}</span> - <Badge variant={vendor.completionPercentage >= 80 ? "default" : vendor.completionPercentage >= 50 ? "secondary" : "destructive"}> - {vendor.completionPercentage}% - </Badge> - </div> - ))} - </div> - </ScrollArea> - </CardContent> - </Card> - </div> - ); - - const renderVendorAllContracts = (summary: VendorAllContractsCompletionSummary) => ( - <div className="space-y-4"> - <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold text-purple-600">{summary.overallCompletionPercentage}%</div> - <p className="text-sm text-muted-foreground">전체 완성도</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold">{summary.totalContracts}</div> - <p className="text-sm text-muted-foreground">총 계약</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold">{summary.totalForms}</div> - <p className="text-sm text-muted-foreground">총 폼</p> - </CardContent> - </Card> - <Card> - <CardContent className="p-4"> - <div className="text-2xl font-bold">{summary.totalFilledFields}/{summary.totalRequiredFields}</div> - <p className="text-sm text-muted-foreground">입력 필드</p> - </CardContent> - </Card> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <Card> - <CardHeader> - <CardTitle className="text-lg">프로젝트별 분석</CardTitle> - </CardHeader> - <CardContent> - <ScrollArea className="h-48"> - <div className="space-y-2"> - {summary.projectBreakdown.map((project, index) => ( - <div key={index} className="flex items-center justify-between p-2 border rounded"> - <div> - <div className="font-medium">{project.projectName}</div> - <div className="text-sm text-muted-foreground"> - 계약 {project.contractsCount}개, 폼 {project.formsCount}개 - </div> - </div> - <Badge variant={project.completionPercentage >= 80 ? "default" : "secondary"}> - {project.completionPercentage}% - </Badge> - </div> - ))} - </div> - </ScrollArea> - </CardContent> - </Card> - - <Card> - <CardHeader> - <CardTitle className="text-lg">계약별 세부 현황</CardTitle> - </CardHeader> - <CardContent> - <ScrollArea className="h-48"> - <div className="space-y-2"> - {summary.contracts.map((contract, index) => ( - <div key={index} className="flex items-center justify-between p-2 border rounded"> - <div> - <div className="font-medium">{contract.itemName}</div> - <div className="text-sm text-muted-foreground"> - {contract.projectName} - 폼 {contract.totalForms}개 - </div> - </div> - <Badge variant={contract.averageCompletionPercentage >= 80 ? "default" : "secondary"}> - {contract.averageCompletionPercentage}% - </Badge> - </div> - ))} - </div> - </ScrollArea> - </CardContent> - </Card> - </div> - </div> - ); + React.useEffect(() => { + loadVendorProgress(); + }, []); return ( <div className="container mx-auto p-6 space-y-6"> - <div className="flex items-center gap-2 mb-6"> - <TestTube className="h-6 w-6" /> - <h1 className="text-3xl font-bold">EDP Progress 서버 액션 테스트</h1> + <div className="flex items-center justify-between mb-6"> + <div className="flex items-center gap-2"> + <Users className="h-6 w-6" /> + <h1 className="text-3xl font-bold">벤더 진척도 현황</h1> + </div> + <Button + onClick={loadVendorProgress} + disabled={loading} + className="flex items-center gap-2" + > + {loading ? <Loader className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />} + 새로고침 + </Button> </div> - {/* Input Parameters */} + {/* Vendor Progress List */} <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> - <FileText className="h-5 w-5" /> - 테스트 파라미터 - </CardTitle> - <CardDescription> - 아래 값들을 수정하여 다양한 시나리오를 테스트할 수 있습니다. - </CardDescription> + <CardTitle>벤더별 작업 진척도</CardTitle> </CardHeader> <CardContent> - <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> - <div className="space-y-2"> - <Label htmlFor="contractItemId">Contract Item ID</Label> - <Input - id="contractItemId" - value={contractItemId} - onChange={(e) => setContractItemId(e.target.value)} - placeholder="123" - /> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <Loader className="h-6 w-6 animate-spin mr-2" /> + <span>벤더 진척도를 불러오는 중...</span> </div> - <div className="space-y-2"> - <Label htmlFor="formCode">Form Code</Label> - <Input - id="formCode" - value={formCode} - onChange={(e) => setFormCode(e.target.value)} - placeholder="SPR_LST" - /> + ) : vendorProgress.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 벤더 데이터가 없습니다. </div> + ) : ( <div className="space-y-2"> - <Label htmlFor="projectId">Project ID</Label> - <Input - id="projectId" - value={projectId} - onChange={(e) => setProjectId(e.target.value)} - placeholder="1" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="vendorId">Vendor ID</Label> - <Input - id="vendorId" - value={vendorId} - onChange={(e) => setVendorId(e.target.value)} - placeholder="1" - /> + {/* Header */} + <div className="grid grid-cols-6 gap-4 p-3 bg-muted rounded-lg font-semibold text-sm"> + <div>벤더명</div> + <div className="text-center">폼 개수</div> + <div className="text-center">태그 개수</div> + <div className="text-center">전체 필드</div> + <div className="text-center">입력 필드</div> + <div className="text-center">완성도</div> + </div> + + {/* Vendor Rows */} + <ScrollArea className="h-96"> + <div className="space-y-1"> + {vendorProgress.map((vendor) => ( + <div key={vendor.vendorId} className="grid grid-cols-6 gap-4 p-3 border rounded-lg hover:bg-muted/50"> + <div className="font-medium">{vendor.vendorName}</div> + <div className="text-center">{vendor.totalForms}</div> + <div className="text-center">{vendor.tagCount}</div> + <div className="text-center">{vendor.totalRequiredFields}</div> + <div className="text-center">{vendor.totalFilledFields}</div> + <div className="text-center"> + <Badge + variant={ + vendor.completionPercentage >= 80 ? "default" : + vendor.completionPercentage >= 50 ? "secondary" : + "destructive" + } + > + {vendor.completionPercentage}% + </Badge> + </div> + </div> + ))} + </div> + </ScrollArea> </div> - </div> + )} </CardContent> </Card> - - {/* Test Buttons */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <BarChart className="h-5 w-5" /> - 테스트 액션들 - </CardTitle> - </CardHeader> - <CardContent> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> - <Button - onClick={() => handleTest('vendor-form', () => - calculateVendorFormCompletion(Number(contractItemId), formCode) - )} - disabled={loading !== null} - className="h-auto p-4 flex flex-col items-start space-y-2" - > - {loading === 'vendor-form' && <Loader className="h-4 w-4 animate-spin" />} - <div className="font-semibold">벤더 폼 완성도</div> - <div className="text-sm opacity-80">특정 벤더의 특정 폼 완성도</div> - </Button> - - <Button - onClick={() => handleTest('project-summary', () => - getProjectVendorCompletionSummary(Number(projectId), formCode) - )} - disabled={loading !== null} - variant="outline" - className="h-auto p-4 flex flex-col items-start space-y-2" - > - {loading === 'project-summary' && <Loader className="h-4 w-4 animate-spin" />} - <div className="font-semibold">프로젝트 요약</div> - <div className="text-sm opacity-80">프로젝트의 모든 벤더 완성도</div> - </Button> - - <Button - onClick={() => handleTest('vendor-contract', () => - calculateVendorContractCompletion(Number(vendorId), Number(contractItemId)) - )} - disabled={loading !== null} - variant="outline" - className="h-auto p-4 flex flex-col items-start space-y-2" - > - {loading === 'vendor-contract' && <Loader className="h-4 w-4 animate-spin" />} - <div className="font-semibold">벤더 계약 완성도</div> - <div className="text-sm opacity-80">특정 벤더의 특정 계약 완성도</div> - </Button> - - <Button - onClick={() => handleTest('vendor-all-contracts', () => - getVendorAllContractsCompletionSummary(Number(vendorId)) - )} - disabled={loading !== null} - variant="secondary" - className="h-auto p-4 flex flex-col items-start space-y-2" - > - {loading === 'vendor-all-contracts' && <Loader className="h-4 w-4 animate-spin" />} - <div className="font-semibold">벤더 전체 계약</div> - <div className="text-sm opacity-80">벤더의 모든 계약 완성도</div> - </Button> - - <Button - onClick={() => handleTest('all-vendors', () => - getAllVendorsContractsCompletionSummary() - )} - disabled={loading !== null} - variant="secondary" - className="h-auto p-4 flex flex-col items-start space-y-2" - > - {loading === 'all-vendors' && <Loader className="h-4 w-4 animate-spin" />} - <div className="font-semibold">전체 벤더 요약</div> - <div className="text-sm opacity-80">모든 벤더의 계약 완성도</div> - </Button> - - <Button - onClick={() => handleTest('all-projects', () => - getAllProjectsVendorCompletionSummary() - )} - disabled={loading !== null} - variant="secondary" - className="h-auto p-4 flex flex-col items-start space-y-2" - > - {loading === 'all-projects' && <Loader className="h-4 w-4 animate-spin" />} - <div className="font-semibold">전체 프로젝트 요약</div> - <div className="text-sm opacity-80">모든 프로젝트의 벤더 완성도</div> - </Button> - </div> - </CardContent> - </Card> - - <Separator /> - - {/* Results */} - {results && ( - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <TrendingUp className="h-5 w-5" /> - 테스트 결과: {results.type} - </CardTitle> - </CardHeader> - <CardContent> - {!results.data ? ( - <div className="text-center py-8 text-muted-foreground"> - 데이터가 없습니다. 파라미터를 확인해주세요. - </div> - ) : results.type === 'vendor-form' ? ( - renderVendorFormStats(results.data as VendorFormCompletionStats) - ) : results.type === 'project-summary' ? ( - renderProjectSummary(results.data as ProjectVendorCompletionSummary) - ) : results.type === 'vendor-all-contracts' ? ( - renderVendorAllContracts(results.data as VendorAllContractsCompletionSummary) - ) : ( - <div className="space-y-4"> - <div className="bg-muted p-4 rounded-lg"> - <pre className="text-sm overflow-auto max-h-96"> - {JSON.stringify(results.data, null, 2)} - </pre> - </div> - </div> - )} - </CardContent> - </Card> - )} </div> ); -} +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx index b675aed1..eb5e62d0 100644 --- a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx @@ -61,7 +61,7 @@ export default async function SettingsLayout({ {/* 4) 입찰 정보가 있으면 번호 + 제목 + "상세 정보" 표기 */} <h2 className="text-2xl font-bold tracking-tight"> {bidding - ? `${bidding.biddingNumber ?? ""} - ${bidding.title}` + ? `입찰 No. ${bidding.biddingNumber ?? ""} - ${bidding.title}` : "Loading Bidding..."} </h2> </div> diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx index e2c22b22..64d6d740 100644 --- a/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx +++ b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx @@ -1,7 +1,8 @@ import { Suspense } from 'react' import { notFound } from 'next/navigation' import { getBiddingDetailData } from '@/lib/bidding/detail/service' -import { BiddingDetailContent } from '@/lib/bidding/detail/table/bidding-detail-content' +import { getBiddingCompanies } from '@/lib/bidding/pre-quote/service' +import { BiddingPreQuoteContent } from '@/lib/bidding/pre-quote/table/bidding-pre-quote-content' // 메타데이터 생성 export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { @@ -38,13 +39,17 @@ export default async function Page({ params }: PageProps) { notFound() } + // 사전견적용 입찰 업체들 조회 + const biddingCompaniesResult = await getBiddingCompanies(parsedId) + const biddingCompanies = biddingCompaniesResult.success ? biddingCompaniesResult.data : [] + return ( <Suspense fallback={<div className="p-8">로딩 중...</div>}> - <BiddingDetailContent + <BiddingPreQuoteContent bidding={detailData.bidding} quotationDetails={detailData.quotationDetails} quotationVendors={detailData.quotationVendors} - biddingCompanies={detailData.biddingCompanies} + biddingCompanies={biddingCompanies} prItems={detailData.prItems} /> </Suspense> diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx index 1b058801..999bfe8b 100644 --- a/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx @@ -4,9 +4,12 @@ import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" import { formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { ArrowLeft, Clock, AlertTriangle, CheckCircle, XCircle, AlertCircle } from "lucide-react" import { RfqsLastView } from "@/db/schema" import { findRfqLastById } from "@/lib/rfq-last/service" +import { differenceInDays } from "date-fns" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" export const metadata: Metadata = { title: "견적 목록 상세", @@ -23,30 +26,92 @@ export default async function RfqLayout({ // 1) URL 파라미터에서 id 추출, Number로 변환 const resolvedParams = await params const lng = resolvedParams.lng - const id = resolvedParams.id + const rfqId = parseInt(resolvedParams.id, 10); + + if (!rfqId || isNaN(rfqId) || rfqId <= 0) { + return ( + <div className="p-4"> + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertTitle>오류</AlertTitle> + <AlertDescription> + 유효하지 않은 RFQ입니다. + </AlertDescription> + </Alert> + </div> + ); + } + - const idAsNumber = Number(id) // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqsLastView | null = await findRfqLastById(idAsNumber) + const rfq: RfqsLastView | null = await findRfqLastById(rfqId) // 3) 사이드바 메뉴 const sidebarNavItems = [ { title: "견적 문서관리", - href: `/${lng}/evcp/rfq-last/${id}`, + href: `/${lng}/evcp/rfq-last/${rfqId}`, }, { title: "RFQ 발송", - href: `/${lng}/evcp/rfq-last/${id}/vendor`, + href: `/${lng}/evcp/rfq-last/${rfqId}/vendor`, }, ] + // Due Date 상태 계산 함수 + const getDueDateStatus = (dueDate: Date | string | null) => { + if (!dueDate) return null; + + const now = new Date(); + const due = new Date(dueDate); + const daysLeft = differenceInDays(due, now); + + if (daysLeft < 0) { + return { + icon: <XCircle className="h-4 w-4" />, + text: `${Math.abs(daysLeft)}일 지남`, + className: "text-red-600", + bgClassName: "bg-red-50" + }; + } else if (daysLeft === 0) { + return { + icon: <AlertTriangle className="h-4 w-4" />, + text: "오늘 마감", + className: "text-orange-600", + bgClassName: "bg-orange-50" + }; + } else if (daysLeft <= 3) { + return { + icon: <AlertCircle className="h-4 w-4" />, + text: `${daysLeft}일 남음`, + className: "text-amber-600", + bgClassName: "bg-amber-50" + }; + } else if (daysLeft <= 7) { + return { + icon: <Clock className="h-4 w-4" />, + text: `${daysLeft}일 남음`, + className: "text-blue-600", + bgClassName: "bg-blue-50" + }; + } else { + return { + icon: <CheckCircle className="h-4 w-4" />, + text: `${daysLeft}일 남음`, + className: "text-green-600", + bgClassName: "bg-green-50" + }; + } + }; + + const dueDateStatus = rfq?.dueDate ? getDueDateStatus(rfq.dueDate) : null; + return ( <> <div className="container py-6"> <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> + <div className="flex items-center justify-end mb-4"> <Link href={`/${lng}/evcp/rfq-last`} passHref> <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> <ArrowLeft className="mr-1 h-4 w-4" /> @@ -55,25 +120,38 @@ export default async function RfqLayout({ </Link> </div> <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} + {/* 제목 로직 수정: rfqTitle 있으면 사용, 없으면 rfqCode만 표시 */} <h2 className="text-2xl font-bold tracking-tight"> {rfq - ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}` + ? rfq.rfqTitle + ? `견적 상세 관리 ${rfq.rfqCode ?? ""} | ${rfq.rfqTitle}` + : `견적 상세 관리 ${rfq.rfqCode ?? ""}` : "Loading RFQ..."} </h2> - - <p className="text-muted-foreground"> - RFQ 관리하는 화면입니다. - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3> + + {/* <p className="text-muted-foreground"> + RFQ 관리하는 화면입니다. + </p> */} + + {/* Due Date 표시 개선 */} + {rfq?.dueDate && dueDateStatus && ( + <div className="flex items-center gap-3 pt-2"> + <span className="text-sm font-medium text-muted-foreground">Due Date:</span> + <strong className="text-sm">{formatDate(rfq.dueDate, "KR")}</strong> + <div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${dueDateStatus.bgClassName} ${dueDateStatus.className}`}> + {dueDateStatus.icon} + <span className="text-xs font-medium">{dueDateStatus.text}</span> + </div> + </div> + )} </div> <Separator className="my-6" /> <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> + <aside className="lg:w-64 flex-shrink-0"> + <SidebarNav items={sidebarNavItems} /> </aside> <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> + </div> </div> </section> </div> diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx index 6819e122..1ccb7559 100644 --- a/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx @@ -3,6 +3,9 @@ import { type SearchParams } from "@/types/table" import { getValidFilters } from "@/lib/data-table" import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations" import { getRfqLastAttachments } from "@/lib/rfq-last/service" +import { RfqAttachmentsTable } from "@/lib/rfq-last/attachment/rfq-attachments-table" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" +import { AlertCircle } from "lucide-react" interface IndexPageProps { // Next.js 13 App Router에서 기본으로 주어지는 객체들 @@ -16,21 +19,61 @@ interface IndexPageProps { export default async function RfqPage(props: IndexPageProps) { const resolvedParams = await props.params const lng = resolvedParams.lng - const id = resolvedParams.id + const rfqId = parseInt(resolvedParams.id, 10); - const idAsNumber = Number(id) + if (!rfqId || isNaN(rfqId) || rfqId <= 0) { + return ( + <div className="p-4"> + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertTitle>오류</AlertTitle> + <AlertDescription> + 유효하지 않은 RFQ입니다. + </AlertDescription> + </Alert> + </div> + ); + } // 2) SearchParams 파싱 (Zod) // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsRfqAttachmentsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) + const searchParams = await props.searchParams; + const activeTab = searchParams.tab || '설계'; + + // 활성 탭에 따라 다른 파라미터 파싱 + const designSearch = activeTab === '설계' + ? searchParamsRfqAttachmentsCache.parse({ + ...searchParams, + // design_ prefix가 붙은 파라미터들 추출 + page: searchParams.design_page, + perPage: searchParams.design_perPage, + sort: searchParams.design_sort, + filters: searchParams.design_filters, + }) + : { page: 1, perPage: 10, sort: [], filters: [] }; + + const purchaseSearch = activeTab === '구매' + ? searchParamsRfqAttachmentsCache.parse({ + ...searchParams, + // purchase_ prefix가 붙은 파라미터들 추출 + page: searchParams.purchase_page, + perPage: searchParams.purchase_perPage, + sort: searchParams.purchase_sort, + filters: searchParams.purchase_filters, + }) + : { page: 1, perPage: 10, sort: [], filters: [] }; + + // 활성 탭의 데이터만 실제로 가져오기 + const [designData, purchaseData] = await Promise.all([ + activeTab === '설계' + ? getRfqLastAttachments({ ...designSearch }, rfqId, "설계") + : { data: [], pageCount: 0 }, + activeTab === '구매' + ? getRfqLastAttachments({ ...purchaseSearch }, rfqId, "구매") + : { data: [], pageCount: 0 } + ]); - const promises = getRfqLastAttachments({ - ...search, - filters: validFilters, - }, idAsNumber) // 4) 렌더링 return ( @@ -45,7 +88,11 @@ export default async function RfqPage(props: IndexPageProps) { </div> <Separator /> <div> - {/* <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} /> */} + <RfqAttachmentsTable + rfqId={rfqId} + initialDesignData={designData} + initialPurchaseData={purchaseData} + /> </div> </div> ) diff --git a/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx b/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx new file mode 100644 index 00000000..6364f7f8 --- /dev/null +++ b/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx @@ -0,0 +1,97 @@ +import { PartnersBiddingPreQuote } from '@/lib/bidding/vendor/partners-bidding-pre-quote' +import { Suspense } from 'react' +import { Skeleton } from '@/components/ui/skeleton' + +import { getServerSession } from 'next-auth' +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +interface PartnersPreQuotePageProps { + params: Promise<{ + id: string + }> +} + +export default async function PartnersPreQuotePage(props: PartnersPreQuotePageProps) { + const resolvedParams = await props.params + const biddingId = parseInt(resolvedParams.id) + + if (isNaN(biddingId)) { + return ( + <div className="container mx-auto py-6"> + <div className="text-center"> + <h1 className="text-2xl font-bold text-destructive">유효하지 않은 입찰 ID입니다.</h1> + </div> + </div> + ) + } + + // 세션에서 companyId 가져오기 + const session = await getServerSession(authOptions) + const companyId = session?.user?.companyId + + if (!companyId) { + return ( + <div className="container mx-auto py-6"> + <div className="text-center"> + <h1 className="text-2xl font-bold text-destructive">회사 정보가 없습니다. 다시 로그인 해주세요.</h1> + </div> + </div> + ) + } + + return ( + <div className="container mx-auto py-6"> + <Suspense fallback={<PreQuoteSkeleton />}> + <PartnersBiddingPreQuote + biddingId={biddingId} + companyId={companyId} + /> + </Suspense> + </div> + ) +} + +function PreQuoteSkeleton() { + return ( + <div className="space-y-6"> + {/* 헤더 스켈레톤 */} + <div className="flex items-center justify-between"> + <div className="space-y-2"> + <Skeleton className="h-8 w-64" /> + <Skeleton className="h-4 w-48" /> + </div> + </div> + + {/* 입찰 공고 스켈레톤 */} + <div className="space-y-4"> + <Skeleton className="h-8 w-32" /> + <div className="space-y-2"> + {Array.from({ length: 6 }).map((_, i) => ( + <Skeleton key={i} className="h-6 w-full" /> + ))} + </div> + </div> + + {/* 현재 설정된 조건 스켈레톤 */} + <div className="space-y-4"> + <Skeleton className="h-8 w-32" /> + <div className="grid grid-cols-2 gap-4"> + {Array.from({ length: 8 }).map((_, i) => ( + <Skeleton key={i} className="h-16 w-full" /> + ))} + </div> + </div> + + {/* 사전견적 폼 스켈레톤 */} + <div className="space-y-4"> + <Skeleton className="h-8 w-32" /> + <div className="space-y-4"> + {Array.from({ length: 10 }).map((_, i) => ( + <Skeleton key={i} className="h-10 w-full" /> + ))} + <Skeleton className="h-12 w-32" /> + </div> + </div> + </div> + ) +} |
